package com.google.jessewilson.codesearch.googlecodesearch;

import com.google.jessewilson.codesearch.api.CodeFile;
import com.google.jessewilson.codesearch.api.CodeFileExtra;
import com.google.jessewilson.codesearch.api.ContentRoot;
import com.google.jessewilson.codesearch.api.FileOrigin;
import com.google.jessewilson.codesearch.api.Line;
import com.google.jessewilson.codesearch.api.Page;
import com.google.jessewilson.codesearch.api.SearchResult;
import com.google.jessewilson.codesearch.api.spi.SearchProviderBackend;
import com.google.jessewilson.codesearch.io.Path;
import com.google.jessewilson.codesearch.util.Preconditions;
import com.google.jessewilson.codesearch.util.ProducerSink;
import com.google.jessewilson.codesearch.util.Reflection;

import com.publicobject.misc.xml.Converter;
import com.publicobject.misc.xml.Converters;
import com.publicobject.misc.xml.ExceptionHandler;
import com.publicobject.misc.xml.Parser;
import com.publicobject.misc.xml.PopProcessor;
import com.publicobject.misc.xml.Processors;
import com.publicobject.misc.xml.XMLTagPath;

import org.xml.sax.SAXException;

import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
import java.io.StringWriter;
import java.net.URI;
import java.net.URISyntaxException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.TimeZone;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Convert XML from the Google Code Search service into {@link CodeFile} objects.
 *
 * @author jessewilson
 */
class EntryParser {

  private static final DateFormat updatedDateFormat;

  static {
    updatedDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
    updatedDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
  }

  private final SearchProviderBackend searchProviderBackend;

  public EntryParser(SearchProviderBackend searchProviderBackend) {
    this.searchProviderBackend = searchProviderBackend;
  }

  public SearchResult parse(InputStream in, final ProducerSink<Line> out, final Page page)
      throws IOException {

    final Parser parser = new Parser();

    // if anything fails, stop the search
    parser.setExceptionHandler(new ExceptionHandler() {
      public void handle(Exception e) {
        if (e instanceof RuntimeException) {
          throw (RuntimeException)e;
        } else {
          throw new RuntimeException(e);
        }
      }
    });

    final XMLTagPath feedTag = new XMLTagPath("feed");
    parser.addProcessor(feedTag.child("id"),
        Processors.setterMethod(SearchResult.class, "id", Converters.trimAndIntern()));
    parser.addProcessor(feedTag.child("updated"),
        Processors.setterMethod(SearchResult.class, "updated", Converters.date(updatedDateFormat)));
    parser.addProcessor(feedTag.child("title"),
        Processors.setterMethod(SearchResult.class, "title", Converters.trimAndIntern()));
    parser.addProcessor(feedTag.child("generator"),
        Processors.setterMethod(SearchResult.class, "generator", Converters.trimAndIntern()));
    parser.addProcessor(feedTag.child("opensearch:totalResults"),
        Processors.setterMethod(SearchResult.class, "totalResults", Converters.integer()));
    parser.addProcessor(feedTag.child("opensearch:startIndex"), new StartIndexProcessor(page));
    parser.addProcessor(feedTag.child("author").child("name"),
        Processors.setterMethod(SearchResult.class, "authorName", Converters.trimAndIntern()));
    parser.addProcessor(feedTag.child("author").child("uri"),
        Processors.setterMethod(SearchResult.class, "authorUri", UriConverter.INSTANCE));

    final XMLTagPath entryTag = feedTag.child("entry");
    parser.addProcessor(entryTag.start(), Processors.createNewObject(CodeFile.class));
    parser.addProcessor(entryTag.child("id"),
        Processors.setterMethod(CodeFile.class, "id", Converters.trimAndIntern()));
    parser.addProcessor(entryTag.child("updated"),
        Processors.setterMethod(CodeFile.class, "updated", Converters.date(updatedDateFormat)));
    parser.addProcessor(entryTag.child("author").child("name"),
        new ExtraDataProcessor<FileOrigin>(FileOrigin.class, "authorName", Converters.trimAndIntern()));
    parser.addProcessor(entryTag.child("author").child("uri"),
        new ExtraDataProcessor<FileOrigin>(FileOrigin.class, "authorUri", UriConverter.INSTANCE));
    parser.addProcessor(entryTag.child("title"),
        new ExtraDataProcessor<FileOrigin>(FileOrigin.class, "title", Converters.trimAndIntern()));
    parser.addProcessor(entryTag.child("gcs:package").attribute("name"),
        Processors.setterMethod(CodeFile.class, "contentRoot", ContentRootConverter.INSTANCE));
    parser.addProcessor(entryTag.child("gcs:file").attribute("name"),
        Processors.setterMethod(CodeFile.class, "path", PathConverter.INSTANCE));
    parser.addProcessor(entryTag.end(),
        new PopProcessor<SearchResult,CodeFile>() {
          public void process(SearchResult searchResult, CodeFile codeFile) {
            codeFile.setSearchProvider(searchProviderBackend);
          }
        });
    parser.addProcessor(entryTag.child("rights"),
        new ExtraDataProcessor<FileOrigin>(FileOrigin.class, "rights", Converters.trimAndIntern()));
    final XMLTagPath matchTag = entryTag.child("gcs:match");
    parser.addProcessor(matchTag.start(), Processors.createNewObject(Line.class));
    parser.addProcessor(matchTag.attribute("lineNumber"),
        Processors.setterMethod(Line.class, "lineNumber", OneBasedInteger.INSTANCE));
    parser.addProcessor(matchTag.body(),
        Processors.setterMethod(Line.class, "lineValue", LineValueConverter.INSTANCE));
    parser.addProcessor(matchTag.end(),
        new PopProcessor<CodeFile,Line>() {
          public void process(CodeFile codeFile, Line line) {
            line.setCodeFile(codeFile);
            out.produced(line);
          }
        });
    SearchResult searchResult = new SearchResult();
    parser.parse(in, searchResult);
    return searchResult;
  }

  private static final class ExtraDataProcessor<T extends CodeFileExtra> implements PopProcessor<CodeFile,String> {
    private final Class<T> extraClass;
    private final PopProcessor<T,String> setterMethod;
    public ExtraDataProcessor(Class<T> extraClass, String property, Converter<String,?> converter) {
      this.extraClass = extraClass;
      this.setterMethod = Processors.setterMethod(extraClass, property, converter);
    }
    public void process(CodeFile codeFile, String string) {
      T extra = codeFile.getCodeFileExtra(extraClass);
      if (extra == null) {
        extra = Reflection.newInstance(extraClass, null);
        Preconditions.checkNotNull(extra);
        codeFile.setCodeFileExtra(extraClass,  extra);
      }

      setterMethod.process(extra, string);
    }
  }

  private static final class UriConverter implements Converter<String, URI> {
    private static final Converter<String, URI> INSTANCE = new UriConverter();
    public URI convert(String string) {
      try {
        return new URI(string.trim());
      } catch (URISyntaxException e) {
        Logger.getLogger(EntryParser.class.getName())
            .log(Level.WARNING, "Failed to parse URI, \"" + string + "\"", e);
        return null;
      }
    }
  }

  private static final class ContentRootConverter implements Converter<String, ContentRoot> {
    private static final Converter<String, ContentRoot> INSTANCE = new ContentRootConverter();
    public ContentRoot convert(String string) {
      return ContentRoot.fromString(string.trim());
    }
  }

  private static final class PathConverter implements Converter<String, Path> {
    private static final Converter<String, Path> INSTANCE = new PathConverter();
    public Path convert(String string) {
      return Path.fromString(string.trim());
    }
  }

  /**
   * Convert HTML code snippets to plaintext.
   */
  private static final class LineValueConverter implements Converter<String,String> {
    private static final Converter<String, String> INSTANCE = new LineValueConverter();
    public String convert(String html) {
      try {
        StringReader stringReader = new StringReader(html);
        StringWriter stringWriter = new StringWriter();
        HtmlToCode htmlToCode = new HtmlToCode(stringReader, stringWriter);
        htmlToCode.extractCode();
        return stringWriter.toString();
      } catch(IOException e) {
        Logger.getLogger(LineValueConverter.class.getName())
            .log(Level.SEVERE, "Unexpected IOException converting " + html, e);
        return "?";
      } catch(SAXException e) {
        Logger.getLogger(LineValueConverter.class.getName())
            .log(Level.SEVERE, "Unexpected SAXException converting " + html, e);
        return "?";
      }
    }
  }

  /**
   * Convert an String containing an Integer in the range "1..N" to an
   * integer in the range "0..(n-1)".
   */
  private static final class OneBasedInteger implements Converter<String, Integer> {
    private static final Converter<String, Integer> INSTANCE = new OneBasedInteger();
    public Integer convert(String s) {
      return Converters.integer().convert(s) - 1;
    }
  }

  /**
   * Halt the search if the start index returned by GCS is not the one requested.
   * Unfortunately, GCS doesn't the number of available results, so this is
   * necessary.
   */
  private static class StartIndexProcessor implements PopProcessor<SearchResult,String> {
    private final Page page;
    public StartIndexProcessor(Page page) {
      this.page = page;
    }
    public void process(SearchResult searchResult, String s) {
      Integer startIndex = new OneBasedInteger().convert(s);
      if (startIndex != page.getStart()) {
        throw new EndOfResultsException(page, startIndex);
      } else {
        searchResult.setStartIndex(startIndex);
      }
    }
  }
}
